TypeScript 50.6%
JavaScript 46.1%
SQL 2.1%
Shell 0.6%
CSS 0.5%
1"use client";2import { useCallback, useEffect, useMemo, useRef, useState } from "react";3import Link from "next/link";4import { useRouter } from "next/navigation";5import { PROPERTY_TYPES, PROPERTY_TYPE_LABEL, STATUS_LABEL } from "@/lib/listings/types";6import { REGIONS } from "@/lib/normalize";7import { fmtPrice } from "@/lib/format";8import { Notice } from "@/components/ui";9import { MapView } from "@/components/map-view";10import { IconClose } from "@/components/icons";1112type Media = { id: string; url: string; large: string; altText: string; position: number };13export type WizardData = { id: string; slug: string; status: string; version: number; wizardStep: number; attestedAt: string | null; moderationNote: string | null; listingType: "SALE" | "RENT"; sourceType: "DIRECT_OWNER" | "DIRECT_AGENT"; propertyType: string; address: string | null; unit: string | null; city: string | null; region: string | null; postalCode: string | null; lat: number | null; lng: number | null; roleUnitId: string | null; privacyLevel: string; bedrooms: number | null; bathrooms: number | null; areaSqft: number | null; lotSqft: number | null; yearBuilt: number | null; title: string | null; description: string | null; priceAmount: number | null; availabilityDate: string | null; furnished: boolean | null; petsPolicy: string | null; smokingPolicy: string | null; parking: string | null; leaseTerms: string | null; inclusions: string[]; features: Record<string, string | number | boolean>; media: Media[] };1415const STEPS = ["Type", "Adresse", "Caractéristiques", "Conditions", "Photos", "Description", "Vérification", "Aperçu"];16const INCLUSIONS_RENT = ["Chauffage", "Électricité", "Eau chaude", "Internet", "Stationnement", "Laveuse-sécheuse", "Réfrigérateur", "Cuisinière", "Lave-vaisselle", "Climatisation", "Meublé", "Balcon", "Rangement", "Ascenseur", "Gym", "Piscine", "Accessible (mobilité réduite)"];17const INCLUSIONS_SALE = ["Garage", "Piscine", "Foyer", "Climatisation centrale", "Thermopompe", "Sous-sol aménagé", "Bord de l'eau", "Vue", "Terrain boisé", "Rénové récemment", "Accessible (mobilité réduite)", "Électroménagers inclus"];1819export function Wizard({ initial }: { initial: WizardData }) {20 const [d, setD] = useState<WizardData>(initial); const [step, setStep] = useState(Math.min(initial.wizardStep, STEPS.length - 1));21 const [saving, setSaving] = useState<"idle" | "saving" | "saved" | "error">("idle"); const [err, setErr] = useState<string | null>(null); const [problems, setProblems] = useState<string[]>([]);22 const router = useRouter(); const dirty = useRef<Partial<WizardData>>({}); const timer = useRef<ReturnType<typeof setTimeout> | null>(null);23 const rent = d.listingType === "RENT";24 const set = (patch: Partial<WizardData>) => { setD((x) => ({ ...x, ...patch })); Object.assign(dirty.current, patch); setSaving("saving"); if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(save, 700); };25 const save = useCallback(async (extra: Partial<WizardData> = {}) => {26 const patch = { ...dirty.current, ...extra }; dirty.current = {}; if (!Object.keys(patch).length) { setSaving("saved"); return true; }27 const body: Record<string, unknown> = { version: d.version };28 for (const k of ["listingType", "sourceType", "propertyType", "address", "unit", "city", "region", "postalCode", "lat", "lng", "roleUnitId", "privacyLevel", "bedrooms", "bathrooms", "areaSqft", "lotSqft", "yearBuilt", "title", "description", "priceAmount", "availabilityDate", "furnished", "petsPolicy", "smokingPolicy", "parking", "leaseTerms", "inclusions", "features", "wizardStep"] as const) if (k in patch) body[k] = (patch as Record<string, unknown>)[k];29 const r = await fetch(`/api/listings/${d.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); const j = await r.json();30 if (!r.ok) { setSaving("error"); setErr(j.error?.code === "conflict" ? "Cette annonce a été modifiée ailleurs : rechargez la page." : j.error?.issues?.map((i: { path: string; message: string }) => `${i.path} : ${i.message}`).join(" · ") ?? j.error?.message ?? "Erreur d'enregistrement"); return false; }31 setD((x) => ({ ...x, version: j.listing.version, status: j.listing.status })); setSaving("saved"); setErr(null); return true;32 }, [d.id, d.version]);33 useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);34 const go = async (n: number) => { if (timer.current) clearTimeout(timer.current); const ok = await save({ wizardStep: n }); if (ok) { setStep(n); window.scrollTo({ top: 0, behavior: "smooth" }); } };3536 // ── Adresse : recherche dans le rôle d'évaluation (3,7 M d'unités) pour géocoder sans service externe37 const [adjust, setAdjust] = useState(false);38 const [addrQ, setAddrQ] = useState(""); const [addrSug, setAddrSug] = useState<{ id: string; address: string; unit: string | null; city: string; type_prop: string; year_built: number | null; floor_area_m2: number | null }[]>([]);39 useEffect(() => { if (addrQ.trim().length < 4) { setAddrSug([]); return; } const t = setTimeout(() => fetch(`/api/estimate?q=${encodeURIComponent(addrQ)}`).then((r) => r.json()).then((j) => setAddrSug(j.units ?? [])).catch(() => {}), 200); return () => clearTimeout(t); }, [addrQ]);40 const pickUnit = async (u: (typeof addrSug)[number]) => { const r = await fetch(`/api/estimate?id=${encodeURIComponent(u.id)}`).then((r) => r.json()).catch(() => null); const lat = r?.subject?.lat ?? null, lng = r?.subject?.lng ?? null; set({ address: u.address, unit: u.unit, city: u.city, lat, lng, roleUnitId: u.id, yearBuilt: d.yearBuilt ?? u.year_built ?? null, areaSqft: d.areaSqft ?? (u.floor_area_m2 ? Math.round(u.floor_area_m2 * 10.7639) : null) }); setAddrSug([]); setAddrQ(""); };4142 // ── Photos43 const [uploading, setUploading] = useState<string[]>([]); const fileRef = useRef<HTMLInputElement>(null);44 const upload = async (files: FileList | File[]) => {45 for (const f of Array.from(files)) {46 setUploading((u) => [...u, f.name]);47 try {48 const pre = await fetch(`/api/listings/${d.id}/media/presign`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ fileName: f.name, mimeType: f.type || "application/octet-stream", bytes: f.size }) }); const pj = await pre.json(); if (!pre.ok) throw new Error(pj.error?.message ?? "Erreur");49 const up = await fetch(pj.uploadUrl, { method: "PUT", headers: { "content-type": f.type || "application/octet-stream" }, body: f }); const uj = await up.json(); if (!up.ok) throw new Error(uj.error?.message ?? "Photo refusée");50 setD((x) => ({ ...x, media: [...x.media, { id: uj.mediaId, url: uj.url.replace("/800.webp", "/320.webp"), large: uj.url, altText: "", position: x.media.length }] }));51 } catch (e) { setErr(`${f.name} : ${(e as Error).message}`); }52 finally { setUploading((u) => u.filter((n) => n !== f.name)); }53 }54 };55 const removeMedia = async (id: string) => { await fetch(`/api/listings/${d.id}/media/${id}`, { method: "DELETE" }); setD((x) => ({ ...x, media: x.media.filter((m) => m.id !== id) })); };56 const moveMedia = async (i: number, dir: -1 | 1) => { const arr = [...d.media]; const j = i + dir; if (j < 0 || j >= arr.length) return; [arr[i], arr[j]] = [arr[j], arr[i]]; setD((x) => ({ ...x, media: arr })); await fetch(`/api/listings/${d.id}/media/confirm`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ order: arr.map((m) => m.id) }) }); };57 const caption = async (id: string, alt: string) => { setD((x) => ({ ...x, media: x.media.map((m) => m.id === id ? { ...m, altText: alt } : m) })); await fetch(`/api/listings/${d.id}/media/confirm`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ captions: { [id]: alt } }) }); };5859 const attest = async () => { const r = await fetch(`/api/listings/${d.id}/attest`, { method: "POST" }); if (r.ok) setD((x) => ({ ...x, attestedAt: new Date().toISOString() })); };60 const publish = async () => { if (timer.current) clearTimeout(timer.current); if (!(await save())) return; setProblems([]); const r = await fetch(`/api/listings/${d.id}/publish`, { method: "POST" }); const j = await r.json(); if (r.status === 422) { setProblems(j.problems); return; } if (!r.ok) { setErr(j.error?.message ?? "Erreur"); return; } setD((x) => ({ ...x, status: j.status })); router.refresh(); };6162 const F = useMemo(() => ({63 num: (k: keyof WizardData, label: string, opts: { step?: number; min?: number; max?: number; suffix?: string; help?: string } = {}) => (64 <label className="block text-sm"><span className="tv-label">{label}</span><div className="relative mt-1"><input type="number" inputMode="decimal" step={opts.step ?? 1} min={opts.min} max={opts.max} value={(d[k] as number | null) ?? ""} onChange={(e) => set({ [k]: e.target.value === "" ? null : Number(e.target.value) } as Partial<WizardData>)} className="tv-chip w-full !pl-3 !pr-10 tabular-nums" />{opts.suffix && <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-ink-3">{opts.suffix}</span>}</div>{opts.help && <span className="text-[11px] text-ink-3">{opts.help}</span>}</label>),65 text: (k: keyof WizardData, label: string, opts: { placeholder?: string; max?: number; help?: string } = {}) => (66 <label className="block text-sm"><span className="tv-label">{label}</span><input type="text" maxLength={opts.max} placeholder={opts.placeholder} value={(d[k] as string | null) ?? ""} onChange={(e) => set({ [k]: e.target.value || null } as Partial<WizardData>)} className="tv-chip w-full !pl-3 mt-1" />{opts.help && <span className="text-[11px] text-ink-3">{opts.help}</span>}</label>),67 // eslint-disable-next-line react-hooks/exhaustive-deps68 }), [d]);6970 const cardIndicator = <span className={`text-xs ${saving === "error" ? "text-brand" : "text-ink-3"}`}>{saving === "saving" ? "Enregistrement…" : saving === "saved" ? "Brouillon enregistré" : saving === "error" ? "Non enregistré" : ""}</span>;71 const incl = rent ? INCLUSIONS_RENT : INCLUSIONS_SALE;72 const isLand = d.propertyType === "terrain", isCom = d.propertyType === "commercial";7374 return (75 <div>76 <div className="flex flex-wrap items-center justify-between gap-2 mb-3">77 <div><Link href="/membre/annonces" className="text-xs text-ink-3 hover:underline">← Mes annonces</Link><h1 className="text-2xl font-extrabold tracking-tight">{d.status === "DRAFT" ? "Publier une propriété" : `Modifier l'annonce · ${STATUS_LABEL[d.status]}`}</h1></div>78 {cardIndicator}79 </div>80 <ol className="flex gap-1 overflow-x-auto no-scrollbar mb-5 text-xs" aria-label="Étapes">{STEPS.map((s, i) => <li key={s}><button type="button" onClick={() => go(i)} className={`tv-chip !py-1 ${i === step ? "!bg-brand !text-white !border-brand" : i < step ? "!border-brand/40" : ""}`} aria-current={i === step ? "step" : undefined}>{i + 1}. {s}</button></li>)}</ol>81 {err && <div className="mb-3"><Notice tone="warn">{err}</Notice></div>}82 {d.moderationNote && ["PENDING_REVIEW", "REJECTED"].includes(d.status) && <div className="mb-3"><Notice tone="warn">Vérification : {d.moderationNote}. {d.status === "REJECTED" ? "Corrigez l'annonce puis republiez ; vous pouvez contester via Signaler un problème → « autre »." : "Un modérateur examinera l'annonce ; vous serez avisé par courriel."}</Notice></div>}8384 <div className="tv-card p-4 md:p-6">85 {step === 0 && (<div className="space-y-5">86 <div><p className="tv-label mb-2">Je souhaite</p><div className="grid grid-cols-2 gap-2">{(["RENT", "SALE"] as const).map((t) => <button key={t} type="button" onClick={() => set({ listingType: t })} className={`tv-card p-4 text-left ${d.listingType === t ? "!border-brand ring-2 ring-brand-soft" : ""}`}><span className="font-bold">{t === "RENT" ? "Louer" : "Vendre"}</span><span className="block text-xs text-ink-2">{t === "RENT" ? "Logement, condo, maison, chambre…" : "Maison, condo, plex, chalet, terrain…"}</span></button>)}</div></div>87 <div><p className="tv-label mb-2">Type de bien</p><div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{PROPERTY_TYPES.map((t) => <button key={t} type="button" onClick={() => set({ propertyType: t })} className={`tv-chip justify-center ${d.propertyType === t ? "!bg-brand-soft !border-brand !text-brand-dark" : ""}`}>{PROPERTY_TYPE_LABEL[t]}</button>)}</div></div>88 <div><p className="tv-label mb-2">Je publie en tant que</p><div className="grid grid-cols-2 gap-2">{([["DIRECT_OWNER", "Propriétaire", "Je suis propriétaire (ou mandaté par écrit) du bien."], ["DIRECT_AGENT", "Représentant", "Je représente le propriétaire (courtier, gestionnaire). La mention « professionnel vérifié » n'apparaît qu'après vérification par Toveli."]] as const).map(([v, l, h]) => <button key={v} type="button" onClick={() => set({ sourceType: v })} className={`tv-card p-3 text-left ${d.sourceType === v ? "!border-brand ring-2 ring-brand-soft" : ""}`}><span className="font-semibold text-sm">{l}</span><span className="block text-xs text-ink-2">{h}</span></button>)}</div></div>89 </div>)}9091 {step === 1 && (<div className="space-y-4">92 <div className="relative"><label className="block text-sm"><span className="tv-label">Rechercher l'adresse (rôle d'évaluation du Québec)</span><input value={addrQ} onChange={(e) => setAddrQ(e.target.value)} placeholder="ex. 1234 rue des Érables Québec" className="tv-input !pl-4 mt-1" /></label>93 {addrSug.length > 0 && <ul className="absolute z-20 mt-1 w-full tv-card p-1 max-h-72 overflow-auto">{addrSug.map((u) => <li key={u.id}><button type="button" onMouseDown={() => pickUnit(u)} className="w-full text-left px-3 py-2 rounded-sm hover:bg-surface-2 text-sm"><span className="font-medium">{u.address}{u.unit ? `, app. ${u.unit}` : ""}</span><span className="block text-xs text-ink-3">{u.city} · {u.type_prop}{u.year_built ? ` · ${u.year_built}` : ""}</span></button></li>)}</ul>}</div>94 <div className="grid sm:grid-cols-2 gap-3">{F.text("address", "Adresse civique", { placeholder: "1234 rue des Érables", max: 200 })}{F.text("unit", "Appartement / unité", { placeholder: "4", max: 20 })}{F.text("city", "Municipalité", { max: 100 })}95 <label className="block text-sm"><span className="tv-label">Région</span><select value={d.region ?? ""} onChange={(e) => set({ region: e.target.value || null })} className="tv-chip w-full mt-1 appearance-none"><option value="">—</option>{REGIONS.map((r) => <option key={r} value={r}>{r}</option>)}</select></label>96 {F.text("postalCode", "Code postal", { placeholder: "G1R 2L3", max: 7 })}97 <label className="block text-sm"><span className="tv-label">Confidentialité de l'adresse</span><select value={d.privacyLevel} onChange={(e) => set({ privacyLevel: e.target.value })} className="tv-chip w-full mt-1 appearance-none"><option value="EXACT">Adresse exacte publiée</option><option value="STREET_ONLY">Rue et municipalité seulement (recommandé en location)</option><option value="AREA_ONLY">Secteur seulement (position approximative)</option></select></label></div>98 <div><div className="flex flex-wrap items-center justify-between gap-2 mb-1"><p className="tv-label">Position sur la carte {d.lat == null && <span className="text-warn">— requise : choisissez une adresse ci-dessus ou activez l'ajustement</span>}</p><label className="flex items-center gap-2 text-xs text-ink-2"><input type="checkbox" checked={adjust} onChange={(e) => { setAdjust(e.target.checked); if (e.target.checked && d.lat == null) set({ lat: 46.81, lng: -71.21 }); }} className="accent-[#e0263c]" /> Ajuster en déplaçant la carte</label></div>99 <div className="h-64 rounded-card overflow-hidden border border-line relative"><MapView points={d.lat != null && d.lng != null && !adjust ? [{ uid: d.id, lat: d.lat, lng: d.lng, price: d.priceAmount ?? 0 }] : []} center={d.lat != null && d.lng != null ? [d.lat, d.lng] : [46.81, -71.21]} zoom={d.lat != null ? 15 : 6} rent={rent} onMove={(b) => { if (adjust) set({ lat: Math.round(((b[1] + b[3]) / 2) * 1e5) / 1e5, lng: Math.round(((b[0] + b[2]) / 2) * 1e5) / 1e5 }); }} />{adjust && <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-full text-brand text-2xl pointer-events-none" aria-hidden>📍</span>}</div>100 <div className="grid grid-cols-2 gap-3 mt-2">{F.num("lat", "Latitude", { step: 0.00001, min: 44, max: 63 })}{F.num("lng", "Longitude", { step: 0.00001, min: -80, max: -57 })}</div></div>101 </div>)}102103 {step === 2 && (<div className="grid sm:grid-cols-2 gap-3">104 {!isLand && !isCom && F.num("bedrooms", "Chambres", { step: 1, min: 0, max: 30 })}{!isLand && !isCom && F.num("bathrooms", "Salles de bain", { step: 0.5, min: 0, max: 20 })}105 {!isLand && F.num("areaSqft", "Superficie habitable", { suffix: "pi²", min: 50, help: "Mesure intérieure ; 1 m² = 10,76 pi²" })}106 {(!rent || isLand) && F.num("lotSqft", "Superficie du terrain", { suffix: "pi²", min: 100 })}107 {!isLand && F.num("yearBuilt", "Année de construction", { min: 1600, max: 2030 })}108 {!isLand && F.text("parking", "Stationnement", { placeholder: "Garage simple + 2 places extérieures", max: 120 })}109 {!isLand && <label className="block text-sm"><span className="tv-label">Étage(s)</span><input type="text" maxLength={40} value={String(d.features.etages ?? "")} onChange={(e) => set({ features: { ...d.features, etages: e.target.value } })} className="tv-chip w-full !pl-3 mt-1" placeholder={rent ? "3e étage, ascenseur" : "2 étages + sous-sol"} /></label>}110 {!isLand && <label className="block text-sm"><span className="tv-label">Chauffage / énergie</span><input type="text" maxLength={80} value={String(d.features.chauffage ?? "")} onChange={(e) => set({ features: { ...d.features, chauffage: e.target.value } })} className="tv-chip w-full !pl-3 mt-1" placeholder="Électrique, plinthes · thermopompe" /></label>}111 <div className="sm:col-span-2"><p className="tv-label mb-2">{rent ? "Inclusions et commodités" : "Caractéristiques"}</p><div className="flex flex-wrap gap-2">{incl.map((x) => <button key={x} type="button" onClick={() => set({ inclusions: d.inclusions.includes(x) ? d.inclusions.filter((y) => y !== x) : [...d.inclusions, x] })} className="tv-chip" data-active={d.inclusions.includes(x)} aria-pressed={d.inclusions.includes(x)}>{x}</button>)}</div></div>112 </div>)}113114 {step === 3 && (<div className="space-y-4">115 <div className="grid sm:grid-cols-2 gap-3">116 {F.num("priceAmount", rent ? "Loyer mensuel demandé" : "Prix demandé", { suffix: "$ CAD", step: rent ? 5 : 1000, min: 0, help: rent ? "Loyer de base par mois, avant frais facultatifs." : "Prix affiché publiquement ; l'estimation Toveli sera montrée à titre indicatif." })}117 {rent && <label className="block text-sm"><span className="tv-label">Disponible</span><input type="date" value={d.availabilityDate && d.availabilityDate !== "now" ? d.availabilityDate : ""} onChange={(e) => set({ availabilityDate: e.target.value || "now" })} className="tv-chip w-full !pl-3 mt-1" /><label className="flex items-center gap-2 text-xs mt-1 text-ink-2"><input type="checkbox" checked={d.availabilityDate === "now" || !d.availabilityDate} onChange={(e) => set({ availabilityDate: e.target.checked ? "now" : null })} className="accent-[#e0263c]" /> Immédiatement</label></label>}118 {rent && <label className="block text-sm"><span className="tv-label">Meublé</span><select value={d.furnished == null ? "" : d.furnished ? "1" : "0"} onChange={(e) => set({ furnished: e.target.value === "" ? null : e.target.value === "1" })} className="tv-chip w-full mt-1 appearance-none"><option value="">—</option><option value="1">Oui</option><option value="0">Non</option></select></label>}119 {rent && <label className="block text-sm"><span className="tv-label">Animaux</span><select value={d.petsPolicy ?? ""} onChange={(e) => set({ petsPolicy: e.target.value || null })} className="tv-chip w-full mt-1 appearance-none"><option value="">—</option><option value="allowed">Acceptés</option><option value="ask">À discuter</option><option value="not_allowed">Refusés</option></select></label>}120 {rent && <label className="block text-sm"><span className="tv-label">Fumeur</span><select value={d.smokingPolicy ?? ""} onChange={(e) => set({ smokingPolicy: e.target.value || null })} className="tv-chip w-full mt-1 appearance-none"><option value="">—</option><option value="not_allowed">Non-fumeur</option><option value="allowed">Permis</option></select></label>}121 {rent && <label className="block text-sm sm:col-span-2"><span className="tv-label">Durée et conditions du bail</span><input type="text" maxLength={400} value={d.leaseTerms ?? ""} onChange={(e) => set({ leaseTerms: e.target.value || null })} className="tv-chip w-full !pl-3 mt-1" placeholder="Bail de 12 mois, formulaire obligatoire du TAL, aucun dépôt exigé" /></label>}122 {!rent && <label className="block text-sm"><span className="tv-label">Taxes municipales annuelles (facultatif)</span><input type="number" inputMode="numeric" value={String(d.features.taxes_municipales ?? "")} onChange={(e) => set({ features: { ...d.features, taxes_municipales: e.target.value ? Number(e.target.value) : "" } })} className="tv-chip w-full !pl-3 mt-1" /></label>}123 {!rent && <label className="block text-sm"><span className="tv-label">Taxes scolaires annuelles (facultatif)</span><input type="number" inputMode="numeric" value={String(d.features.taxes_scolaires ?? "")} onChange={(e) => set({ features: { ...d.features, taxes_scolaires: e.target.value ? Number(e.target.value) : "" } })} className="tv-chip w-full !pl-3 mt-1" /></label>}124 {!rent && d.propertyType === "condo" && <label className="block text-sm"><span className="tv-label">Frais de condo mensuels</span><input type="number" inputMode="numeric" value={String(d.features.frais_condo ?? "")} onChange={(e) => set({ features: { ...d.features, frais_condo: e.target.value ? Number(e.target.value) : "" } })} className="tv-chip w-full !pl-3 mt-1" /></label>}125 </div>126 {rent ? <Notice>Au Québec, le locateur ne peut exiger aucun dépôt de garantie ni paiement d'avance autre que le premier mois de loyer, et doit utiliser le bail obligatoire du Tribunal administratif du logement (TAL). La clause G du bail indique le loyer le plus bas payé au cours des 12 derniers mois. Les critères discriminatoires sont interdits (Charte québécoise).</Notice>127 : <Notice>Le vendeur doit déclarer les vices et défauts connus (déclaration du vendeur). Toveli n'est pas un courtier : la transaction se conclut entre les parties, idéalement avec notaire et inspection. Les droits de mutation sont à la charge de l'acheteur.</Notice>}128 </div>)}129130 {step === 4 && (<div className="space-y-4">131 <div onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); upload(e.dataTransfer.files); }} className="border-2 border-dashed border-line rounded-card p-6 text-center">132 <p className="font-semibold">Glissez vos photos ici</p><p className="text-xs text-ink-3 mt-1">JPEG, PNG, WebP ou HEIC · 25 Mo max par photo · 30 photos max · les données EXIF/GPS sont retirées automatiquement</p>133 <input ref={fileRef} type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic" multiple capture="environment" className="hidden" onChange={(e) => e.target.files && upload(e.target.files)} />134 <div className="mt-3 flex justify-center gap-2"><button type="button" onClick={() => fileRef.current?.click()} className="tv-btn tv-btn-primary">Choisir des photos</button></div>135 {uploading.length > 0 && <p className="text-xs text-ink-2 mt-2">Traitement : {uploading.join(", ")}…</p>}136 </div>137 {d.media.length > 0 && <ul className="grid grid-cols-2 sm:grid-cols-3 gap-3">{d.media.map((m, i) => <li key={m.id} className="tv-card overflow-hidden"><div className="relative aspect-[4/3] bg-surface-2"><img src={m.url} alt={m.altText} className="absolute inset-0 w-full h-full object-cover" />{i === 0 && <span className="absolute top-1.5 left-1.5 tv-badge bg-brand text-white">Photo principale</span>}<button type="button" onClick={() => removeMedia(m.id)} className="absolute top-1.5 right-1.5 p-1 rounded-full bg-white/90" aria-label="Supprimer"><IconClose width={14} height={14} /></button></div><div className="p-2 flex items-center gap-1"><button type="button" onClick={() => moveMedia(i, -1)} className="tv-chip !px-2 !py-0.5" aria-label="Avancer">←</button><button type="button" onClick={() => moveMedia(i, 1)} className="tv-chip !px-2 !py-0.5" aria-label="Reculer">→</button><input value={m.altText} onChange={(e) => caption(m.id, e.target.value)} placeholder="Légende" maxLength={140} className="flex-1 min-w-0 text-xs border-b border-line outline-none py-0.5" /></div></li>)}</ul>}138 <Notice>En téléversant des photos, vous confirmez en détenir les droits et consentez à leur publication sur Toveli. Pas de personnes identifiables ni de documents personnels sur les photos.</Notice>139 </div>)}140141 {step === 5 && (<div className="space-y-4">142 {F.text("title", "Titre de l'annonce", { placeholder: rent ? "Grand 4½ lumineux près du métro, chauffé" : "Maison familiale rénovée, grand terrain, quartier paisible", max: 120, help: "8 à 120 caractères. Pas de majuscules excessives." })}143 <label className="block text-sm"><span className="tv-label">Description</span><textarea value={d.description ?? ""} onChange={(e) => set({ description: e.target.value || null })} rows={10} maxLength={6000} className="w-full mt-1 border border-line rounded-sm p-3 text-sm outline-none focus:border-brand" placeholder="Décrivez le bien, ses atouts, le quartier, les conditions. Ne mentionnez ni téléphone ni courriel : les échanges passent par la messagerie Toveli." /><span className="text-[11px] text-ink-3">{(d.description ?? "").length} / 6000 · 40 caractères minimum</span></label>144 <Notice>Pas d'aide à la rédaction par IA pour l'instant : une description écrite par vous, exacte et sans caractéristique inventée, est ce qui inspire le plus confiance.</Notice>145 </div>)}146147 {step === 6 && (<div className="space-y-4">148 <h2 className="font-bold">Vérification avant publication</h2>149 <label className="flex gap-3 items-start text-sm tv-card p-3"><input type="checkbox" checked={!!d.attestedAt} onChange={(e) => e.target.checked && attest()} disabled={!!d.attestedAt} className="mt-1 accent-[#e0263c]" /><span>J'atteste être le propriétaire du bien ou être dûment autorisé(e) à le publier, que les informations sont exactes, que je détiens les droits sur les photos et que l'annonce respecte les lois applicables au Québec (Code civil, TAL, Charte des droits). Je comprends que Toveli peut suspendre une annonce signalée et que toute usurpation entraîne la fermeture du compte.{d.attestedAt && <span className="block text-xs text-ink-3 mt-1">Attestation enregistrée le {new Date(d.attestedAt).toLocaleString("fr-CA")}.</span>}</span></label>150 <Notice>Votre adresse courriel est vérifiée par le code de connexion. Toveli applique une détection automatique de spam et de doublons ; en cas de doute l'annonce passe en vérification humaine (sous 48 h ouvrables) et vous êtes avisé par courriel. Aucune pièce d'identité n'est demandée par défaut.</Notice>151 </div>)}152153 {step === 7 && (<div className="space-y-4">154 <h2 className="font-bold">Aperçu</h2>155 <div className="tv-card overflow-hidden"><div className="aspect-[16/9] bg-surface-2 relative">{d.media[0] && <img src={d.media[0].large} alt="" className="absolute inset-0 w-full h-full object-cover" />}<span className="absolute top-2 left-2 tv-badge bg-white/90">{rent ? "À louer" : "À vendre"} · {PROPERTY_TYPE_LABEL[d.propertyType]}</span></div>156 <div className="p-4"><p className="tv-price text-2xl">{fmtPrice(d.priceAmount, { rent })}</p><p className="font-semibold">{d.title ?? "Sans titre"}</p><p className="text-sm text-ink-2">{d.privacyLevel === "EXACT" ? d.address : d.privacyLevel === "STREET_ONLY" ? (d.address ?? "").replace(/^\d+\S*\s+/, "") : "Secteur"}{d.city ? `, ${d.city}` : ""}</p><p className="text-xs text-ink-3 mt-1">{[d.bedrooms != null ? `${d.bedrooms} ch.` : null, d.bathrooms != null ? `${d.bathrooms} sdb` : null, d.areaSqft ? `${d.areaSqft} pi²` : null].filter(Boolean).join(" · ")}</p><p className="text-sm mt-3 whitespace-pre-line line-clamp-6">{d.description}</p><p className="mt-3 text-[11px] text-ink-3">Publiée par {d.sourceType === "DIRECT_AGENT" ? "un représentant" : "le propriétaire"} sur Toveli</p></div></div>157 {problems.length > 0 && <div className="tv-card p-3 border-warn/40"><p className="font-semibold text-warn text-sm">À compléter avant publication</p><ul className="list-disc pl-5 text-sm text-ink-2 mt-1">{problems.map((p) => <li key={p}>{p}</li>)}</ul></div>}158 <div className="flex flex-wrap gap-2">159 {["DRAFT", "REJECTED", "PENDING_REVIEW"].includes(d.status) && <button type="button" onClick={publish} className="tv-btn tv-btn-primary">Publier maintenant</button>}160 {d.status === "PUBLISHED" && <><span className="tv-badge bg-good-soft text-good">Publiée</span><a href={`/annonce/${encodeURIComponent("toveli:" + d.slug)}`} target="_blank" rel="noreferrer" className="tv-btn tv-btn-ghost">Voir la page publique</a></>}161 {d.status === "PENDING_REVIEW" && <span className="tv-badge bg-warn-soft text-warn">En vérification</span>}162 <Link href="/membre/annonces" className="tv-btn tv-btn-ghost">Terminer plus tard</Link>163 </div>164 </div>)}165 </div>166 <div className="mt-4 flex justify-between"><button type="button" disabled={step === 0} onClick={() => go(step - 1)} className="tv-btn tv-btn-ghost disabled:opacity-40">Précédent</button>{step < STEPS.length - 1 && <button type="button" onClick={() => go(step + 1)} className="tv-btn tv-btn-primary">Suivant</button>}</div>167 </div>168 );169}170